﻿using System;
using System.Configuration;

using BizElements.Core;

namespace BizElements.BusinessLayer
{
    /// <summary>
    /// Fetches and caches business objects and entities in <see cref="GlobalCache"/>.
    /// </summary>
    /// <remarks>Objects instances which are returned by the cache are deep clones of the cached items. Deep cloning is implemented
    /// by serialization which ensures that cached items cannot be accidently modified by reference. Cached object must be marked
    /// with <see cref="SerializableAttribute"/> attribute.</remarks>
    public static class EntityCache
    {
        #region Configuration.

        /// <summary>
        /// Default absolute expiration in minutes.
        /// </summary>
        /// <value>Number of minutes after which a cached item expires. Default is <see cref="GlobalCache.DefaultAbsoluteExpirationInMinutes"/>.</value>
        /// <remarks>The value can be configured in <b>appSettings</b> section of application's config file:
        /// <code>
        /// &lt;appSettings&gt;
        ///   &lt;add key="BizElements.BusinessLayer.EntityCache.DefaultAbsoluteExpirationInMinutes" value="60" /&gt;
        /// &lt;/appSettings&gt;
        /// </code>
        /// </remarks>
        public static int DefaultAbsoluteExpirationInMinutes { get; private set; }

        static EntityCache()
        {
            int? cfgExpiration = NumberParser.ParseInt(ConfigurationManager.AppSettings["BizElements.BusinessLayer.EntityCache.DefaultAbsoluteExpirationInMinutes"]);
            DefaultAbsoluteExpirationInMinutes = cfgExpiration ?? GlobalCache.DefaultAbsoluteExpirationInMinutes;
        }

        #endregion

        #region GetObject.

        const string ObjectCacheKeyFormat = "ObjectCache-E658C1D1-DF61-4de2-A713-54FB42813A91_{0}_L{1}_PK:{2}";

        /// <summary>
        /// Fetches a business object and caches it.
        /// </summary>
        /// <typeparam name="TBizObject">Type that implements <see cref="IBusinessObject"/> interface and is derived from BizElements generated entity class.</typeparam>
        /// <param name="actionInitiator">Action initiator. This may be an application user, software module, 3rd party 
        /// application etc. which is initiating the fetch action. <b>Null</b> is not allowed.</param>
        /// <param name="detailLevel">Specifies the scope and the detail level of data (related entities) that is to be 
        /// fetched. Specifies the minimum required level. Method may fetch more than requested, but never less.</param>
        /// <param name="entityWithPK">Compatibile entity whose primary key fields have been set to a valid value.</param>
        /// <returns>Requested business objects.</returns>
        /// <exception cref="InvalidOperationException">Generated if the class of requested object is not marked as serializable
        /// or if the object with the provided PK doesn't exist in the data source.</exception>
        public static TBizObject GetObject<TBizObject>(IActor actionInitiator, DataAccessScope detailLevel, IEntity entityWithPK)
            where TBizObject : class, IBusinessObject, IEntity, new()
        {
            if (!typeof(TBizObject).IsSerializable)
                throw new InvalidOperationException(typeof(TBizObject).FullName + " is not marked as Serializable.");

            string pk = ArrayUtil.Concat(entityWithPK.GetPrimaryKeyValue(), ",");
            string cacheKey = string.Format(ObjectCacheKeyFormat, typeof(TBizObject).FullName, detailLevel.Value.ToString("X"), pk);
            byte[] serializedObject = GlobalCache.GetData(cacheKey) as byte[];
            if (serializedObject == null)
            {
                TBizObject bizObject = new TBizObject();
                foreach (IDbColumn pkField in entityWithPK.Table.PrimaryKey)
                    bizObject.SetField(pkField, entityWithPK.GetField(pkField));

                bool exists = bizObject.Fetch(actionInitiator, detailLevel, false);
                if (!exists)
                    throw new InvalidOperationException("Table '" + entityWithPK.Table.TableName + "' doesn't contain a record with the requested PK: " + pk);

                serializedObject = Serializer.SerializeWithBinaryFormater(bizObject);
                GlobalCache.SetData(cacheKey, serializedObject, DefaultAbsoluteExpirationInMinutes, false, bizObject.Table.TableName);
            }

            TBizObject clone = (TBizObject)Serializer.DeserializeWithBinaryFormater(serializedObject);
            return clone;
        }

        /// <summary>
        /// Fetches a business object and caches it.
        /// </summary>
        /// <typeparam name="TBizObject">Type that implements <see cref="IBusinessObject"/> interface and is derived from BizElements generated entity class.</typeparam>
        /// <param name="actionInitiator">Action initiator. This may be an application user, software module, 3rd party 
        /// application etc. which is initiating the fetch action. <b>Null</b> is not allowed.</param>
        /// <param name="detailLevel">Specifies the scope and the detail level of data (related entities) that is to be 
        /// fetched. Specifies the minimum required level. Method may fetch more than requested, but never less.</param>
        /// <param name="id">ID of the object to fetch.</param>
        /// <returns>Requested business objects.</returns>
        /// <exception cref="InvalidOperationException">Generated if the class of requested object is not marked as serializable
        /// or if the object with the provided PK doesn't exist in the data source.</exception>
        public static TBizObject GetObject<TBizObject>(IActor actionInitiator, DataAccessScope detailLevel, int id)
            where TBizObject : class, IBusinessObject, IEntity, new()
        {
            IEntity entityWithPK = new TBizObject();
            DbColumnCollection pk = entityWithPK.Table.PrimaryKey;
            if (pk.Count != 1 || pk[0].DataType != typeof(int))
                throw new InvalidOperationException("PK must be constructed of exactly one 32bit integer field.");

            entityWithPK.SetField(pk[0], id);
            return GetObject<TBizObject>(actionInitiator, detailLevel, entityWithPK);
        }

        /// <summary>
        /// Fetches a business object and caches it.
        /// </summary>
        /// <typeparam name="TBizObject">Type that implements <see cref="IBusinessObject"/> interface and is derived from BizElements generated entity class.</typeparam>
        /// <param name="actionInitiator">Action initiator. This may be an application user, software module, 3rd party 
        /// application etc. which is initiating the fetch action. <b>Null</b> is not allowed.</param>
        /// <param name="detailLevel">Specifies the scope and the detail level of data (related entities) that is to be 
        /// fetched. Specifies the minimum required level. Method may fetch more than requested, but never less.</param>
        /// <param name="id">ID of the object to fetch.</param>
        /// <returns>Requested business objects.</returns>
        /// <exception cref="InvalidOperationException">Generated if the class of requested object is not marked as serializable
        /// or if the object with the provided PK doesn't exist in the data source.</exception>
        public static TBizObject GetObject<TBizObject>(IActor actionInitiator, DataAccessScope detailLevel, string id)
            where TBizObject : class, IBusinessObject, IEntity, new()
        {
            IEntity entityWithPK = new TBizObject();
            DbColumnCollection pk = entityWithPK.Table.PrimaryKey;
            if (pk.Count != 1 || !TypeUtil.IsText(pk[0].DataType))
                throw new InvalidOperationException("PK must be constructed of exactly one textual field.");

            entityWithPK.SetField(pk[0], id);
            return GetObject<TBizObject>(actionInitiator, detailLevel, entityWithPK);
        }

        #endregion

        #region GetEntity.

        const string EntityCacheKeyFormat = "EntityCache-DD3511F3-CA99-45da-B4B1-2AFC05D8C6A9_{0}_L:{1}_PK:{2}";

        /// <summary>
        /// Fethes an entity and caches it.
        /// </summary>
        /// <param name="entityWithPK">Entity whose primary key fields have been set to a valid value. This object is not modified.</param>
        /// <param name="parentsToFetch">Relations (foreign keys) to parents which are going to be fetched.</param>
        /// <returns>Requested entity.</returns>
        /// <exception cref="InvalidOperationException">Generated if the class of requested object is not marked as serializable
        /// or if the object with the provided PK doesn't exist in the data source.</exception>
        public static IEntity GetEntity(IEntity entityWithPK, params DbRelation[] parentsToFetch)
        {
            if (!entityWithPK.GetType().IsSerializable)
                throw new InvalidOperationException(entityWithPK.GetType().FullName + " is not marked as Serializable.");

            string pk = ArrayUtil.Concat(entityWithPK.GetPrimaryKeyValue(), ",");
            string detailLevel = ArrayUtil.Concat<DbRelation>(parentsToFetch, ",", (rel) => (rel.Parent.TableName));
            string cacheKey = string.Format(EntityCacheKeyFormat, entityWithPK.GetType().FullName, detailLevel, pk);
            byte[] serializedEntity = GlobalCache.GetData(cacheKey) as byte[];
            if (serializedEntity == null)
            {
                IEntity fetchedEntity = RpcHelper.InvokeStaticMethod<IEntity, IEntity, DbRelation[]>(DbGetEntity, entityWithPK, parentsToFetch);
                serializedEntity = Serializer.SerializeWithBinaryFormater(fetchedEntity);
                GlobalCache.SetData(cacheKey, serializedEntity, DefaultAbsoluteExpirationInMinutes, false, entityWithPK.Table.TableName);
            }

            IEntity clone = (IEntity)Serializer.DeserializeWithBinaryFormater(serializedEntity);
            return clone;
        }

        private static IEntity DbGetEntity(IEntity entityWithPK, params DbRelation[] parentsToFetch)
        {
            if (!entityWithPK.GetType().IsSerializable)
                throw new InvalidOperationException(entityWithPK.GetType().FullName + " is not marked as Serializable.");

            string pk = ArrayUtil.Concat(entityWithPK.GetPrimaryKeyValue(), ",");
            string detailLevel = ArrayUtil.Concat<DbRelation>(parentsToFetch, ",", (rel) => (rel.Parent.TableName));
            string cacheKey = string.Format(EntityCacheKeyFormat, entityWithPK.GetType().FullName, detailLevel, pk);
            byte[] serializedEntity = GlobalCache.GetData(cacheKey) as byte[];
            if (serializedEntity == null)
            {
                IEntity fetchedEntity = (IEntity)entityWithPK.Clone();
                IEntityDAO dao = EntityDaoFactory.GetEntityDAO(fetchedEntity, null);
                bool exists = dao.SelectOne(parentsToFetch);
                if (!exists)
                    throw new InvalidOperationException("Table '" + entityWithPK.Table.TableName + "' doesn't contain a record with the requested PK: " + pk);

                serializedEntity = Serializer.SerializeWithBinaryFormater(fetchedEntity);
                GlobalCache.SetData(cacheKey, serializedEntity, DefaultAbsoluteExpirationInMinutes, false, entityWithPK.Table.TableName);
            }

            IEntity clone = (IEntity)Serializer.DeserializeWithBinaryFormater(serializedEntity);
            return clone;
        }

        /// <summary>
        /// Fethes an entity and caches it.
        /// </summary>
        /// <typeparam name="TEntity">BizElements generated entity class.</typeparam>
        /// <param name="id">ID of the object to fetch.</param>
        /// <param name="parentsToFetch">Relations (foreign keys) to parents which are going to be fetched.</param>
        /// <returns>Requested entity.</returns>
        /// <exception cref="InvalidOperationException">Generated if the class of requested object is not marked as serializable
        /// or if the object with the provided PK doesn't exist in the data source.</exception>
        public static TEntity GetEntity<TEntity>(int id, params DbRelation[] parentsToFetch)
            where TEntity : IEntity, new()
        {
            IEntity entityWithPK = new TEntity();
            DbColumnCollection pk = entityWithPK.Table.PrimaryKey;
            if (pk.Count != 1 || pk[0].DataType != typeof(int))
                throw new InvalidOperationException("PK must be constructed of exactly one 32bit integer field.");

            entityWithPK.SetField(pk[0], id);
            return (TEntity)GetEntity(entityWithPK, parentsToFetch);
        }

        /// <summary>
        /// Fethes an entity and caches it.
        /// </summary>
        /// <typeparam name="TEntity">BizElements generated entity class.</typeparam>
        /// <param name="id">ID of the object to fetch.</param>
        /// <param name="parentsToFetch">Relations (foreign keys) to parents which are going to be fetched.</param>
        /// <returns>Requested entity.</returns>
        /// <exception cref="InvalidOperationException">Generated if the class of requested object is not marked as serializable
        /// or if the object with the provided PK doesn't exist in the data source.</exception>
        public static TEntity GetEntity<TEntity>(string id, params DbRelation[] parentsToFetch)
            where TEntity : IEntity, new()
        {
            IEntity entityWithPK = new TEntity();
            DbColumnCollection pk = entityWithPK.Table.PrimaryKey;
            if (pk.Count != 1 || !TypeUtil.IsText(pk[0].DataType))
                throw new InvalidOperationException("PK must be constructed of exactly one textual field.");

            entityWithPK.SetField(pk[0], id);
            return (TEntity)GetEntity(entityWithPK, parentsToFetch);
        }

        #endregion

        #region EnsureParents.

        /// <summary>
        /// Gets parents whose FK is defined but parent object is not set from cache. <b>RPC enabled.</b>
        /// </summary>
        /// <param name="child">Child entity which is to be checked and updated.</param>
        /// <param name="parentsToFetch">Relations (foreign keys) to parents which are to be fetched. Child table of the given FK must be the current entity's table; otherwise an exception is generated.</param>
        public static void EnsureParents(IEntity child, params DbRelation[] parentsToFetch)
        {
            if (HasAllParents(child, parentsToFetch))
                return;

            foreach (DbRelation fk in parentsToFetch)
            {
                bool isFkSetButNoParent = IsSetFkValue(child, fk) && (child.GetParent(fk) == null);
                if (isFkSetButNoParent)
                {
                    IEntity parentWithPk = InitParentWithPk(child, fk);
                    IEntity fetchedParent = GetEntity(parentWithPk);
                    child.SetParent(fk, fetchedParent);
                }
            }
        }

        private static IEntity InitParentWithPk(IEntity child, DbRelation fk)
        {
            IEntity parent = fk.Parent.NewEntity();
            for (int keyPart = 0; keyPart < fk.ChildForeignKey.Length; keyPart++)
            {
                IDbColumn pkField = fk.ParentPrimaryKey[keyPart];
                IDbColumn fkField = fk.ChildForeignKey[keyPart];
                object keyValue = child.GetField(fkField);
                parent.SetField(pkField, keyValue);
            }

            return parent;
        }

        private static bool HasAllParents(IEntity child, params DbRelation[] parentsToFetch)
        {
            foreach (DbRelation fk in child.Table.ForeignKeys)
            {
                bool isFkSet = IsSetFkValue(child, fk);
                bool isFkSetButNoParent = isFkSet && (child.GetParent(fk) == null);
                if (isFkSetButNoParent)
                    return false;
            }

            return true;
        }

        private static bool IsSetFkValue(IEntity child, DbRelation fk)
        {
            foreach (IDbColumn fkField in fk.ChildForeignKey)
            {
                if (child.IsNull(fkField))
                    return false;
            }

            return true;
        }

        #endregion
    }
}